package com.yunhe.common.util;

import java.io.Serializable;
import java.time.*;
import java.time.chrono.ChronoPeriod;
import java.time.chrono.IsoChronology;
import java.time.format.DateTimeParseException;
import java.time.temporal.*;
import java.util.*;

import static java.time.temporal.ChronoUnit.*;

/**
 * An amount of time in the ISO-8601 calendar system that combines a period and a duration.
 * <p>
 * This class models a quantity or amount of time in terms of a {@code Period} and {@code Duration}.
 * A period is a date-based amount of time, consisting of years, months and days.
 * A duration is a time-based amount of time, consisting of seconds and nanoseconds.
 * See the {@link Period} and {@link Duration} classes for more details.
 * <p>
 * The days in a period take account of daylight saving changes (23 or 25 hour days).
 * When performing calculations, the period is added first, then the duration.
 * <p>
 * The model is of a directed amount, meaning that the amount may be negative.
 *
 * <h3>Implementation Requirements:</h3>
 * This class is immutable and thread-safe.
 * <p>
 * This class must be treated as a value type. Do not synchronize, rely on the
 * identity hash code or use the distinction between equals() and ==.
 */
public final class PeriodDuration implements TemporalAmount, Serializable {

    /**
     * A constant for a duration of zero.
     */
    public static final PeriodDuration ZERO = new PeriodDuration(Period.ZERO, Duration.ZERO);

    /**
     * A serialization identifier for this class.
     */
    private static final long serialVersionUID = 8815521625671589L;
    /**
     * The supported units.
     */
    private static final List<TemporalUnit> SUPPORTED_UNITS =
            Collections.unmodifiableList(Arrays.<TemporalUnit>asList(YEARS, MONTHS, DAYS, SECONDS, NANOS));
    /**
     * The number of seconds per day.
     */
    private static final long SECONDS_PER_DAY = 86400;

    /**
     * The period.
     */
    private final Period period;
    /**
     * The duration.
     */
    private final Duration duration;

    //-----------------------------------------------------------------------
    /**
     * Obtains an instance based on a period and duration.
     * <p>
     * The total amount of time of the resulting instance is the period plus the duration.
     *
     * @param period  the period, not null
     * @param duration  the duration, not null
     * @return the combined period-duration, not null
     */
    public static PeriodDuration of(Period period, Duration duration) {
        Objects.requireNonNull(period, "The period must not be null");
        Objects.requireNonNull(duration, "The duration must not be null");
        return new PeriodDuration(period, duration);
    }

    /**
     * Obtains an instance based on a period.
     * <p>
     * The duration will be zero.
     *
     * @param period  the period, not null
     * @return the combined period-duration, not null
     */
    public static PeriodDuration of(Period period) {
        Objects.requireNonNull(period, "The period must not be null");
        return new PeriodDuration(period, Duration.ZERO);
    }

    /**
     * Obtains an instance based on a duration.
     * <p>
     * The period will be zero.
     *
     * @param duration  the duration, not null
     * @return the combined period-duration, not null
     */
    public static PeriodDuration of(Duration duration) {
        Objects.requireNonNull(duration, "The duration must not be null");
        return new PeriodDuration(Period.ZERO, duration);
    }

    //-----------------------------------------------------------------------
    /**
     * Obtains an instance from a temporal amount.
     * <p>
     * This obtains an instance based on the specified amount.
     * A {@code TemporalAmount} represents an amount of time which this factory
     * extracts to a {@code PeriodDuration}.
     * <p>
     * The result is calculated by looping around each unit in the specified amount.
     * Any amount that is zero is ignore.
     * If a unit has an exact duration, it will be totalled using {@link Duration#plus(Duration)}.
     * If the unit is days or weeks, it will be totalled into the days part of the period.
     * If the unit is months or quarters, it will be totalled into the months part of the period.
     * If the unit is years, decades, centuries or millennia, it will be totalled into the years part of the period.
     *
     * @param amount  the temporal amount to convert, not null
     * @return the equivalent duration, not null
     * @throws DateTimeException if unable to convert to a {@code Duration}
     * @throws ArithmeticException if numeric overflow occurs
     */
    public static PeriodDuration from(TemporalAmount amount) {
        if (amount instanceof PeriodDuration) {
            return (PeriodDuration) amount;
        }
        if (amount instanceof Period) {
            return PeriodDuration.of((Period) amount);
        }
        if (amount instanceof Duration) {
            return PeriodDuration.of((Duration) amount);
        }
        if (amount instanceof ChronoPeriod) {
            if (IsoChronology.INSTANCE.equals(((ChronoPeriod) amount).getChronology()) == false) {
                throw new DateTimeException("Period requires ISO chronology: " + amount);
            }
        }
        Objects.requireNonNull(amount, "amount");
        int years = 0;
        int months = 0;
        int days = 0;
        Duration duration = Duration.ZERO;
        for (TemporalUnit unit : amount.getUnits()) {
            long value = amount.get(unit);
            if (value != 0) {
                // ignore unless non-zero
                if (unit.isDurationEstimated()) {
                    if (unit == ChronoUnit.DAYS) {
                        days = Math.addExact(days, Math.toIntExact(value));
                    } else if (unit == ChronoUnit.WEEKS) {
                        days = Math.addExact(days, Math.toIntExact(Math.multiplyExact(value, 7)));
                    } else if (unit == ChronoUnit.MONTHS) {
                        months = Math.addExact(months, Math.toIntExact(value));
                    } else if (unit == IsoFields.QUARTER_YEARS) {
                        months = Math.addExact(months, Math.toIntExact(Math.multiplyExact(value, 3)));
                    } else if (unit == ChronoUnit.YEARS) {
                        years = Math.addExact(years, Math.toIntExact(value));
                    } else if (unit == ChronoUnit.DECADES) {
                        years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, 10)));
                    } else if (unit == ChronoUnit.CENTURIES) {
                        years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, 100)));
                    } else if (unit == ChronoUnit.MILLENNIA) {
                        years = Math.addExact(years, Math.toIntExact(Math.multiplyExact(value, 1000)));
                    } else {
                        throw new DateTimeException("Unknown unit: " + unit);
                    }
                } else {
                    // total of exact durations
                    duration = duration.plus(amount.get(unit), unit);
                }
            }
        }
        return PeriodDuration.of(Period.of(years, months, days), duration);
    }

    //-----------------------------------------------------------------------
    /**
     * Obtains an instance from a text string such as {@code PnYnMnDTnHnMnS}.
     * <p>
     * This will parse the string produced by {@code toString()} which is
     * based on the ISO-8601 period formats {@code PnYnMnDTnHnMnS} and {@code PnW}.
     * <p>
     * The string starts with an optional sign, denoted by the ASCII negative
     * or positive symbol. If negative, the whole amount is negated.
     * The ASCII letter "P" is next in upper or lower case.
     * There are then a number of sections, each consisting of a number and a suffix.
     * At least one of the sections must be present.
     * The sections have suffixes in ASCII of "Y" for years, "M" for months,
     * "W" for weeks, "D" for days, "H" for hours, "M" for minutes, "S" for seconds,
     * accepted in upper or lower case. Note that the ASCII letter "T" separates
     * the date and time parts and must be present if any time part is present.
     * The suffixes must occur in order.
     * The number part of each section must consist of ASCII digits.
     * The number may be prefixed by the ASCII negative or positive symbol.
     * The number must parse to an {@code int}.
     * Any week-based input is multiplied by 7 and treated as a number of days.
     * <p>
     * The leading plus/minus sign, and negative values for weeks and days are
     * not part of the ISO-8601 standard.
     * <p>
     * Note that the date style format {@code PYYYY-MM-DDTHH:MM:SS} is not supported.
     * <p>
     * For example, the following are valid inputs:
     * <pre>
     *   "P2Y"             -- PeriodDuration.of(Period.ofYears(2))
     *   "P3M"             -- PeriodDuration.of(Period.ofMonths(3))
     *   "P4W"             -- PeriodDuration.of(Period.ofWeeks(4))
     *   "P5D"             -- PeriodDuration.of(Period.ofDays(5))
     *   "PT6H"            -- PeriodDuration.of(Duration.ofHours(6))
     *   "P1Y2M3D"         -- PeriodDuration.of(Period.of(1, 2, 3))
     *   "P1Y2M3W4DT8H"    -- PeriodDuration.of(Period.of(1, 2, 25), Duration.ofHours(8))
     *   "P-1Y2M"          -- PeriodDuration.of(Period.of(-1, 2, 0))
     *   "-P1Y2M"          -- PeriodDuration.of(Period.of(-1, -2, 0))
     * </pre>
     *
     * @param text  the text to parse, not null
     * @return the parsed period, not null
     * @throws DateTimeParseException if the text cannot be parsed to a period
     */
    public static PeriodDuration parse(CharSequence text) {
        Objects.requireNonNull(text, "text");
        String upper = text.toString().toUpperCase(Locale.ENGLISH);
        String negate = "";
        if (upper.startsWith("+")) {
            upper = upper.substring(1);
        } else if (upper.startsWith("-")) {
            upper = upper.substring(1);
            negate = "-";
        }
        // duration only, parse original text so it does negation
        if (upper.startsWith("PT")) {
            return PeriodDuration.of(Duration.parse(text));
        }
        // period only, parse original text so it does negation
        int tpos = upper.indexOf('T');
        if (tpos < 0) {
            return PeriodDuration.of(Period.parse(text));
        }
        // period and duration
        Period period = Period.parse(negate + upper.substring(0, tpos));
        Duration duration = Duration.parse(negate + "P" + upper.substring(tpos));
        return PeriodDuration.of(period, duration);
    }

    //-----------------------------------------------------------------------
    /**
     * Obtains an instance consisting of the amount of time between two temporals.
     * <p>
     * The start is included, but the end is not.
     * The result of this method can be negative if the end is before the start.
     * <p>
     * The calculation examines the temporals and extracts {@link LocalDate} and {@link LocalTime}.
     * If the time is missing, it will be defaulted to midnight.
     * If one date is missing, it will be defaulted to the other date.
     * It then finds the amount of time between the two dates and between the two times.
     *
     * @param startInclusive  the start, inclusive, not null
     * @param endExclusive  the end, exclusive, not null
     * @return the number of days between this date and the end date, not null
     */
    public static PeriodDuration between(Temporal startInclusive, Temporal endExclusive) {
        LocalDate startDate = startInclusive.query(TemporalQueries.localDate());
        LocalDate endDate = endExclusive.query(TemporalQueries.localDate());
        Period period = Period.ZERO;
        if (startDate != null && endDate != null) {
            period = Period.between(startDate, endDate);
        }
        LocalTime startTime = startInclusive.query(TemporalQueries.localTime());
        LocalTime endTime = endExclusive.query(TemporalQueries.localTime());
        startTime = startTime != null ? startTime : LocalTime.MIDNIGHT;
        endTime = endTime != null ? endTime : LocalTime.MIDNIGHT;
        Duration duration = Duration.between(startTime, endTime);
        return PeriodDuration.of(period, duration);
    }

    //-----------------------------------------------------------------------
    /**
     * Constructs an instance.
     *
     * @param period  the period
     * @param duration  the duration
     */
    private PeriodDuration(Period period, Duration duration) {
        this.period = period;
        this.duration = duration;
    }

    /**
     * Resolves singletons.
     *
     * @return the singleton instance
     */
    private Object readResolve() {
        return PeriodDuration.of(period, duration);
    }

    //-----------------------------------------------------------------------
    /**
     * Gets the value of the requested unit.
     * <p>
     * This returns a value for the supported units - {@link ChronoUnit#YEARS},
     * {@link ChronoUnit#MONTHS}, {@link ChronoUnit#DAYS}, {@link ChronoUnit#SECONDS}
     * and {@link ChronoUnit#NANOS}.
     * All other units throw an exception.
     * Note that hours and minutes throw an exception.
     *
     * @param unit  the {@code TemporalUnit} for which to return the value
     * @return the long value of the unit
     * @throws UnsupportedTemporalTypeException if the unit is not supported
     */
    @Override
    public long get(TemporalUnit unit) {
        if (unit instanceof ChronoUnit) {
            switch ((ChronoUnit) unit) {
                case YEARS:
                    return period.getYears();
                case MONTHS:
                    return period.getMonths();
                case DAYS:
                    return period.getDays();
                case SECONDS:
                    return duration.getSeconds();
                case NANOS:
                    return duration.getNano();
                default:
                    break;
            }
        }
        throw new UnsupportedTemporalTypeException("Unsupported unit: " + unit);
    }

    /**
     * Gets the set of units supported by this amount.
     * <p>
     * This returns the list {@link ChronoUnit#YEARS}, {@link ChronoUnit#MONTHS},
     * {@link ChronoUnit#DAYS}, {@link ChronoUnit#SECONDS} and {@link ChronoUnit#NANOS}.
     * <p>
     * This set can be used in conjunction with {@link #get(TemporalUnit)}
     * to access the entire state of the amount.
     *
     * @return a list containing the days unit, not null
     */
    @Override
    public List<TemporalUnit> getUnits() {
        return SUPPORTED_UNITS;
    }

    //-----------------------------------------------------------------------
    /**
     * Gets the period part.
     *
     * @return the period part
     */
    public Period getPeriod() {
        return period;
    }

    /**
     * Returns a copy of this period-duration with a different period.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @param period  the new period
     * @return the updated period-duration
     */
    public PeriodDuration withPeriod(Period period) {
        return PeriodDuration.of(period, duration);
    }

    /**
     * Gets the duration part.
     *
     * @return the duration part
     */
    public Duration getDuration() {
        return duration;
    }

    /**
     * Returns a copy of this period-duration with a different duration.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @param duration  the new duration
     * @return the updated period-duration
     */
    public PeriodDuration withDuration(Duration duration) {
        return PeriodDuration.of(period, duration);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if all parts of this amount are zero.
     * <p>
     * This returns true if both {@link Period#isZero()} and {@link Duration#isZero()}
     * return true.
     *
     * @return true if this period is zero-length
     */
    public boolean isZero() {
        return period.isZero() && duration.isZero();
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a copy of this amount with the specified amount added.
     * <p>
     * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}.
     * The period and duration are combined separately.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @param amountToAdd  the amount to add, not null
     * @return a {@code Days} based on this instance with the requested amount added, not null
     * @throws DateTimeException if the specified amount contains an invalid unit
     * @throws ArithmeticException if numeric overflow occurs
     */
    public PeriodDuration plus(TemporalAmount amountToAdd) {
        PeriodDuration other = PeriodDuration.from(amountToAdd);
        return of(period.plus(other.period), duration.plus(other.duration));
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a copy of this amount with the specified amount subtracted.
     * <p>
     * The parameter is converted using {@link PeriodDuration#from(TemporalAmount)}.
     * The period and duration are combined separately.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @param amountToAdd  the amount to add, not null
     * @return a {@code Days} based on this instance with the requested amount subtracted, not null
     * @throws DateTimeException if the specified amount contains an invalid unit
     * @throws ArithmeticException if numeric overflow occurs
     */
    public PeriodDuration minus(TemporalAmount amountToAdd) {
        PeriodDuration other = PeriodDuration.from(amountToAdd);
        return of(period.minus(other.period), duration.minus(other.duration));
    }

    //-----------------------------------------------------------------------
    /**
     * Returns an instance with the amount multiplied by the specified scalar.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @param scalar  the scalar to multiply by, not null
     * @return the amount multiplied by the specified scalar, not null
     * @throws ArithmeticException if numeric overflow occurs
     */
    public PeriodDuration multipliedBy(int scalar) {
        if (scalar == 1) {
            return this;
        }
        return of(period.multipliedBy(scalar), duration.multipliedBy(scalar));
    }

    /**
     * Returns an instance with the amount negated.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @return the negated amount, not null
     * @throws ArithmeticException if numeric overflow occurs, which only happens if
     *  the amount is {@code Long.MIN_VALUE}
     */
    public PeriodDuration negated() {
        return multipliedBy(-1);
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a copy of this instance with the years and months exactly normalized.
     * <p>
     * This normalizes the years and months units, leaving the days unit unchanged.
     * The result is exact, always representing the same amount of time.
     * <p>
     * The months unit is adjusted to have an absolute value less than 11,
     * with the years unit being adjusted to compensate. For example, a period of
     * "1 year and 15 months" will be normalized to "2 years and 3 months".
     * <p>
     * The sign of the years and months units will be the same after normalization.
     * For example, a period of "1 year and -25 months" will be normalized to
     * "-1 year and -1 month".
     * <p>
     * Note that no normalization is performed on the days or duration.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @return a {@code PeriodDuration} based on this one with excess months normalized to years, not null
     * @throws ArithmeticException if numeric overflow occurs
     */
    public PeriodDuration normalizedYears() {
        return withPeriod(period.normalized());
    }

    /**
     * Returns a copy of this instance with the days and duration normalized using the standard day of 24 hours.
     * <p>
     * This normalizes the days and duration, leaving the years and months unchanged.
     * The result uses a standard day length of 24 hours.
     * <p>
     * This combines the duration seconds with the number of days and shares the total
     * seconds between the two fields. For example, a period of
     * "2 days and 86401 seconds" will be normalized to "3 days and 1 second".
     * <p>
     * The sign of the days and duration will be the same after normalization.
     * For example, a period of "1 day and -172801 seconds" will be normalized to
     * "-1 day and -1 second".
     * <p>
     * Note that no normalization is performed on the years or months.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @return a {@code PeriodDuration} based on this one with excess duration normalized to days, not null
     * @throws ArithmeticException if numeric overflow occurs
     */
    public PeriodDuration normalizedStandardDays() {
        long totalSecs = period.getDays() * SECONDS_PER_DAY + duration.getSeconds();
        int splitDays = Math.toIntExact(totalSecs / SECONDS_PER_DAY);
        long splitSecs = totalSecs % SECONDS_PER_DAY;
        if (splitDays == period.getDays() && splitSecs == duration.getSeconds()) {
            return this;
        }
        return PeriodDuration.of(period.withDays(splitDays), duration.withSeconds(splitSecs));
    }

    //-----------------------------------------------------------------------
    /**
     * Adds this amount to the specified temporal object.
     * <p>
     * This returns a temporal object of the same observable type as the input
     * with this amount added. This simply adds the period and duration to the temporal.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @param temporal  the temporal object to adjust, not null
     * @return an object of the same type with the adjustment made, not null
     * @throws DateTimeException if unable to add
     * @throws UnsupportedTemporalTypeException if the DAYS unit is not supported
     * @throws ArithmeticException if numeric overflow occurs
     */
    @Override
    public Temporal addTo(Temporal temporal) {
        return temporal.plus(period).plus(duration);
    }

    /**
     * Subtracts this amount from the specified temporal object.
     * <p>
     * This returns a temporal object of the same observable type as the input
     * with this amount subtracted. This simply subtracts the period and duration from the temporal.
     * <p>
     * This instance is immutable and unaffected by this method call.
     *
     * @param temporal  the temporal object to adjust, not null
     * @return an object of the same type with the adjustment made, not null
     * @throws DateTimeException if unable to subtract
     * @throws UnsupportedTemporalTypeException if the DAYS unit is not supported
     * @throws ArithmeticException if numeric overflow occurs
     */
    @Override
    public Temporal subtractFrom(Temporal temporal) {
        return temporal.minus(period).minus(duration);
    }

    //-----------------------------------------------------------------------
    /**
     * Checks if this amount is equal to the specified {@code PeriodDuration}.
     * <p>
     * The comparison is based on the underlying period and duration.
     *
     * @param otherAmount  the other amount, null returns false
     * @return true if the other amount is equal to this one
     */
    @Override
    public boolean equals(Object otherAmount) {
        if (this == otherAmount) {
            return true;
        }
        if (otherAmount instanceof PeriodDuration) {
            PeriodDuration other = (PeriodDuration) otherAmount;
            return this.period.equals(other.period) && this.duration.equals(other.duration);
        }
        return false;
    }

    /**
     * A hash code for this amount.
     *
     * @return a suitable hash code
     */
    @Override
    public int hashCode() {
        return period.hashCode() ^ duration.hashCode();
    }

    //-----------------------------------------------------------------------
    /**
     * Returns a string representation of the amount.
     * This will be in the format 'PnYnMnDTnHnMnS', with sections omitted as necessary.
     * An empty amount will return "PT0S".
     *
     * @return the period in ISO-8601 string format
     */
    @Override
    public String toString() {
        if (period.isZero()) {
            return duration.toString();
        }
        if (duration.isZero()) {
            return period.toString();
        }
        return period.toString() + duration.toString().substring(1);
    }

}